
這篇文章會用 TDD 手刻五大 Scope Functions(let、run、with、apply、also),搞清楚「帶 Receiver 的 Lambda」是什麼,再看它們怎麼跟 Collection 操作搭配
| Kotlin | C# | 備註 |
|---|---|---|
obj.let { } |
無直接對應 | C# 靠 extension method 模擬 |
obj.run { } |
無直接對應 | 類似 let,但用 this |
with(obj) { } |
無直接對應 | 頂層函式,非擴充 |
obj.apply { } |
無直接對應 | 物件初始化常用 |
obj.also { } |
無直接對應 | 類似 C# 的 Tap 擴充 |
C# 沒有 scope function 的概念。有些開發者會自己寫 Tap 或 Pipe 擴充方法來模擬,但不是語言內建的
| 函式 | Lambda 參數 | 回傳值 | 典型用途 |
|---|---|---|---|
let |
it |
Lambda 結果 | null 安全鏈、轉換 |
run |
this |
Lambda 結果 | 物件初始化 + 計算 |
with |
this |
Lambda 結果 | 對同一物件多次操作 |
apply |
this |
物件本身 | 物件初始化 |
also |
it |
物件本身 | 副作用(logging、debug) |
五個函式的差別就在兩個重點:Lambda 裡面用 it 還是 this,以及回傳 Lambda 結果還是物件本身
這五個都是 Kotlin stdlib 函式,不是新的語言語法。它們利用既有的擴充函式與帶 Receiver Lambda,整理出幾種常用組合
差異在 receiver 的設計(day 03 講擴充函式時介紹過 receiver 的概念)
C# 的 extension method 是 static 函式加上一點語法糖。this T 參數讓呼叫端寫起來像實例方法,但 Lambda 內部沒有「receiver」這個概念,Lambda 只能透過明確命名的參數存取物件。自己寫一個 Tap
public static T Tap<T>(this T value, Action<T> action) {
action(value);
return value;
}
// 呼叫端(示意,實際要寫在方法內)
obj.Tap(it => it.DoSomething()); // 必須寫 it.DoSomething()
Kotlin 的 T.() -> R 把 receiver 帶進 Lambda 內部,this 直接是 receiver,可以省略
obj.apply { doSomething() } // 不需要寫 this. 或 it.
差別累積起來就不一樣。物件分配、集合初始化、多個 setter 串起來,Kotlin 寫法明顯乾淨
至於為什麼要五個而不是一個,是 Kotlin 把兩個正交的軸切開了
this 還是 it,this 適合對同一物件做多次操作,it 適合需要明確命名的情況(尤其物件是 nullable)兩個軸交叉是四種組合,加上 with 是頂層函式版的 run,湊成五個。每一個都對應一種真實使用情境,不是為多而多
T.() -> R這是理解 scope function 差異的核心
// 普通 Lambda:T 當參數,用 it 存取
val normalLambda: (String) -> Int = { it.length }
// 帶 Receiver 的 Lambda:T 當 receiver,用 this 存取(可省略)
val receiverLambda: String.() -> Int = { length }
(T) -> R 和 T.() -> R 的功能一樣,差在語法。帶 Receiver 的 Lambda 裡面可以直接呼叫 receiver 的方法,不需要寫 it.
這就是 let(用 it) 和 run(用 this) 的根本差異
@Test
fun `let transforms value`() {
val result = "hello".myLet { it.length }
assertEquals(5, result)
}
@Test
fun `let with null safety`() {
val name: String? = "Kotlin"
val result = name?.myLet { "Hello, $it" }
assertEquals("Hello, Kotlin", result)
}
@Test
fun `run uses this as receiver`() {
val result = "hello".myRun { length }
assertEquals(5, result)
}
myLet 的 Lambda 用 it.length,myRun 的 Lambda 直接寫 length。因為 run 的 Lambda 是 T.() -> R,receiver 就是 "hello" 本身
inline fun <T, R> T.myLet(block: (T) -> R): R {
return block(this)
}
inline fun <T, R> T.myRun(block: T.() -> R): R {
return block()
}
兩個函式各一行。差別只在 Lambda 的型別宣告
myLet 接收 (T) -> R,把 this 當參數傳進去。myRun 接收 T.() -> R,直接呼叫 block(),因為 receiver 已經是 this 了
兩個函式都是單一運算式,可以改寫成 expression body,跟 stdlib 的寫法一致
inline fun <T, R> T.myLet(block: (T) -> R): R = block(this)
inline fun <T, R> T.myRun(block: T.() -> R): R = block()
兩個都標 inline,因為 Lambda 在函式裡面直接執行(不會被存起來),正好符合 day 04 講的 inline 使用條件。stdlib 還多了 contract,這部分留到本篇後面的「與 stdlib 原始碼比較」一起看
@Test
fun `with operates on receiver`() {
val result = myWith("hello") { length }
assertEquals(5, result)
}
@Test
fun `apply returns the object itself`() {
val list = ArrayList<Int>().myApply {
add(1)
add(2)
add(3)
}
assertEquals(listOf(1, 2, 3), list)
}
@Test
fun `also for side effects in chain`() {
var sideEffect = ""
val result = listOf(1, 2, 3)
.filter { it > 1 }
.myAlso { sideEffect = "filtered: $it" }
.map { it * 10 }
assertEquals(listOf(20, 30), result)
assertEquals("filtered: [2, 3]", sideEffect)
}
@Test
fun `apply on empty collection returns the same instance`() {
// 邊界案例:空集合上 apply,Lambda 什麼都不加,仍回傳原物件
val empty = ArrayList<Int>()
val result = empty.myApply { }
assertSame(empty, result)
assertEquals(emptyList<Int>(), result)
}
@Test
fun `also runs on nullable receiver via safe call`() {
// 例外/null 行為:nullable receiver 用 ?. 接 also,null 時整段不執行
var sideEffect = "untouched"
val nothing: String? = null
val result = nothing?.myAlso { sideEffect = "ran with $it" }
assertNull(result)
assertEquals("untouched", sideEffect)
}
inline fun <T, R> myWith(receiver: T, block: T.() -> R): R {
return receiver.block()
}
inline fun <T> T.myApply(block: T.() -> Unit): T {
block()
return this
}
inline fun <T> T.myAlso(block: (T) -> Unit): T {
block(this)
return this
}
myWith 跟 myRun 很像,差在 with 是頂層函式,receiver 用參數傳入。myApply 用 this 當 receiver,回傳 this。myAlso 用 it 存取物件,也回傳 this
myWith 是單一運算式,一樣改成 expression body。myApply 和 myAlso 因為先有副作用、再 return this,是兩行邏輯,stdlib 本身也維持區塊寫法,這裡就跟著保留
inline fun <T, R> myWith(receiver: T, block: T.() -> R): R = receiver.block()
整理一下五個函式的實作差異
| 函式 | Lambda 型別 | 回傳 |
|---|---|---|
myLet |
(T) -> R |
block(this) |
myRun |
T.() -> R |
block() |
myWith |
T.() -> R |
receiver.block() |
myApply |
T.() -> Unit |
this |
myAlso |
(T) -> Unit |
this |
day 10 講 mapNotNull 時,就用過 ?.let 把 nullable 攤平,那是 let 在 Collection 操作裡第一次登場。這裡把它擴大到整條鏈
val employees = listOf(
Employee(1, "Alice", "Engineering", 85000, 30),
Employee(2, "Bob", "Engineering", 72000, 25)
)
// filter 結果可能是空的,用 takeIf + let 處理
employees
.filter { it.salary > 80000 }
.takeIf { it.isNotEmpty() }
?.let { highPaid -> println("高薪人數: ${highPaid.size}") }
takeIf 回傳 null 或原本的 List,接上 ?.let 只在非 null 時執行。不需要寫 if-else
listOf(1, 2, 3, 4, 5)
.filter { it > 2 }
.also { println("filter 後: $it") } // debug 用
.map { it * 10 }
.also { println("map 後: $it") } // debug 用
also 回傳物件本身,不影響 pipeline。debug 完直接拿掉就好,不需要改動其他程式碼
當你需要先處理一份 Collection,再把結果組成一段字串或一個物件,可以用非擴充版 run { } 開一個 scope,把中間步驟放在裡面,最後回傳整理好的結果
它與前面手刻的 obj.myRun { } 不同,這個版本沒有 receiver,只是立即執行區塊並回傳最後一個運算式
val employees = listOf(
Employee(1, "Alice", "Engineering", 85000, 30),
Employee(2, "Bob", "Sales", 72000, 25)
)
val report = run {
val byDept = employees.groupBy { it.department }
buildString {
byDept.forEach { (dept, members) ->
appendLine("$dept: ${members.size} 人")
}
}
}
run 回傳 Lambda 最後一行的結果,這裡就是 buildString 產出的字串。byDept 這個中間變數只活在 scope 內,不會外洩到周圍,把「先分組、再組字串」這段邏輯包成一個運算式
順帶一提,apply 在這種場景常拿來初始化集合,例如 HashMap<String, Any>().apply { put(...) },回傳物件本身、可以直接賦值,差別在 run 回傳的是計算結果,apply 回傳的是物件本身
scope function 很方便,但濫用會讓程式碼變難讀。兩個常見的反模式
let 巢狀堆疊
// ❌ 三層 let,縮排和 ?. 鏈一樣多,沒省到事
employee?.let { e ->
e.address?.let { a ->
a.city?.let { c -> println(c) }
}
}
// ✅ 直接用 ?. 鏈
employee?.address?.city?.let(::println)
apply 空殼
// ❌ Lambda 裡面什麼都沒做
val name = "Alice".apply { /* nothing */ }
apply 的價值在「對 receiver 做多個操作」,Lambda 是空的或只有一行,直接賦值更清楚
挑選 scope function 的判斷順序
?. 鏈apply(初始化)、with(讀取)also
?.let
原始碼位置:kotlin 的 Standard.kt
stdlib 的主體邏輯與手刻版本相同
// stdlib 的 let
public inline fun <T, R> T.let(block: (T) -> R): R {
contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) }
return block(this)
}
唯一的差別是 stdlib 加了 contract。callsInPlace(block, EXACTLY_ONCE) 告訴 compiler:這個 Lambda 一定會被執行正好一次。這讓 let 裡面可以初始化 val 變數
五大 Scope Functions 的實作都只有一兩行
差異在 Lambda 型別與回傳值:(T) -> R 使用 it,T.() -> R 使用 this;let、run、with 回傳 Lambda 結果,apply、also 回傳物件本身。搭配 Collection 操作時,let 可處理 null 安全鏈,also 適合插入 debug,run 則能把多步驟計算收進同一個 scope
下一篇深入 operator 和 infix,看怎麼讓自訂型別支援 [] 和 + 等運算子語法
同步刊登於 Blog
圖片來源:AI 產生